Skip to content

feat: add workspace tabs — nested tab layer within each workspace#2234

Open
oobagi wants to merge 3 commits intomanaflow-ai:mainfrom
oobagi:feat/workspace-tabs
Open

feat: add workspace tabs — nested tab layer within each workspace#2234
oobagi wants to merge 3 commits intomanaflow-ai:mainfrom
oobagi:feat/workspace-tabs

Conversation

@oobagi
Copy link
Copy Markdown

@oobagi oobagi commented Mar 27, 2026

Summary

Adds a tab layer between workspaces (sidebar) and split panes, so each workspace can have multiple independent layouts — e.g., one tab with a fullscreen browser, another with a 6-pane terminal grid.

  • New WorkspaceTab model — extracts BonsplitController + panel state from Workspace with computed pass-throughs for backward compatibility
  • Tab bar UI — auto-hides with 1 tab, appears with 2+. No layout shift on hover (opacity toggle for close button). Tooltips show shortcut hints.
  • ZStack rendering — each tab's BonsplitView stays alive in a ZStack (opacity toggle), preventing AppKit portal ghosting when switching tabs
  • 6 keyboard shortcuts⌥⌘] (next), ⌥⌘[ (prev), ⌥⌘T (new), ⌥⌘R (rename), ⌥⌘W (close)
  • Command palette — 5 commands registered with conditional visibility (Close/Next/Prev hidden when only 1 tab)
  • Rename flow — uses existing command palette rename infrastructure (not NSAlert)
  • BonsplitDelegate safety — all 11 delegate methods resolve owning tab via controller identity (===), preventing cross-tab state corruption
  • Publisher safety — workspace-level activePanels mirror stays connected across tab switches
  • Force-unwrap safety — all ~80 selectedWorkspaceTab! replaced with activeWorkspaceTab (descriptive fatalError)
  • Session persistence — backward-compatible optional workspaceTabs array on SessionWorkspaceSnapshot
  • View menu items — Next/Previous/New/Rename/Close Workspace Tab with shortcuts

Test plan

  • Build compiles cleanly (xcodebuild, 0 errors)
  • 1,184 unit tests pass (0 unexpected failures)
  • 0 remaining selectedWorkspaceTab! force-unwraps
  • All 11 BonsplitDelegate methods guarded with workspaceTab(for:)
  • No browser homepage remnants (scoped to workspace tabs only)
  • Session persistence backward compat (old snapshots decode with nil, single-tab fallback)
  • Manual: create/switch/close/rename workspace tabs via shortcuts and command palette
  • Manual: verify no ghost terminals when switching tabs (ZStack rendering)
  • Manual: verify tab bar auto-hides with 1 tab
  • Manual: verify independent split layouts per tab

🤖 Generated with Claude Code


Summary by cubic

Adds workspace tabs: a nested tab layer inside each workspace so users can keep multiple, independent split layouts per workspace. Improves tab switching stability, persistence, and shortcuts, and integrates cleanly with the latest upstream changes.

  • New Features

    • Multiple tabs per workspace with an auto-hiding tab bar; per-tab rename; each tab’s split view stays alive via ZStack to prevent AppKit ghosting.
    • Shortcuts: ⌥⌘] (next), ⌥⌘[ (prev), ⌥⌘T (new), ⌥⌘R (rename), ⌥⌘W (close). Commands added to the command palette and View menu.
    • Session persistence adds optional workspaceTabs and selectedWorkspaceTabIndex; legacy single-tab snapshots still load; custom tab titles persist.
    • Tab bar tooltips show shortcut hints; no layout shift when revealing the close button.
  • Bug Fixes

    • Workspace-wide ops iterate all tabs; closing a tab checks for running processes, prompts, and cleans up surfaces/notifications.
    • Resolved shortcut collision: “Close Other Tabs in Pane” moved to ⌥⌘X and is configurable via KeyboardShortcutSettings.
    • Fixed retain cycle in file-drop handling; per-tab interactivity and drop targets wired correctly.
    • Persistence/restore: autosave fingerprint includes tab count and selected index; custom titles saved/restored; cleans up Welcome tabs.
    • UX: tab titles prefer the focused panel’s directory; localized “New Tab”; rename pre-fills with the derived title.
    • Merged upstream main and resolved conflicts; workspace tabs remain compatible with Niri canvas, Ghostty sync, command palette updates, remote child-exit tracking, and titlebar double‑click changes.

Written for commit 02aa81d. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Multi-tab workspaces: create, navigate, rename, and close workspace tabs with a visual tab bar and per-tab titles (custom or derived)
    • Command-palette rename flow for workspace tabs
    • Configurable keyboard shortcuts for tab actions and "close other tabs in pane"
    • Session persistence and restore for multi-tab workspace layouts (backwards-compatible)
  • Tests

    • Added tests for workspace-tab snapshot encoding/decoding and restore behavior

Adds a tab layer between workspaces (sidebar) and split panes, allowing
each workspace to have multiple independent layouts. For example, one tab
can hold a fullscreen browser while another has a 6-pane terminal grid.

Architecture:
- New WorkspaceTab class extracts BonsplitController + panel state from Workspace
- Workspace delegates panel operations through computed pass-throughs
- BonsplitViews rendered in a ZStack (opacity toggle) to avoid AppKit portal ghosting
- BonsplitDelegate callbacks resolve owning tab via controller identity (===)
- panelsPublisher uses workspace-level activePanels mirror to stay connected across tab switches
- All ~80 force-unwraps replaced with activeWorkspaceTab (descriptive fatalError)

Features:
- Tab bar auto-hides with 1 tab, appears with 2+
- Keyboard shortcuts: ⌥⌘] (next), ⌥⌘[ (prev), ⌥⌘T (new), ⌥⌘R (rename), ⌥⌘W (close)
- Command palette integration (5 commands, conditional visibility)
- Rename uses existing command palette rename flow
- Session persistence with backward-compatible optional fields
- Tab bar tooltips show customizable shortcut hints
- No layout shift on close button hover (opacity toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 27, 2026

@oobagi is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

Adds multi-tab workspace support: new WorkspaceTab model and tab bar views, refactors Workspace/TabManager for per-tab state and session persistence, wires workspace-tab keyboard shortcuts and command‑palette rename flow, and switches panel observation to panelsPublisher.

Changes

Cohort / File(s) Summary
Project Configuration
GhosttyTabs.xcodeproj/project.pbxproj
Added project references and build entries to include two new Swift source files (WorkspaceTab.swift, WorkspaceTabBarView.swift) in the GhosttyTabs target.
Workspace Tab Core
Sources/WorkspaceTab.swift
New WorkspaceTab class: per-tab BonsplitController, published per-tab panel maps/metadata, focus/terminal helpers, lifecycle/restore bookkeeping, and computed title/focus helpers.
Workspace Model & Restore
Sources/Workspace.swift, Sources/SessionPersistence.swift
Refactored Workspace to own workspaceTabs and selectedWorkspaceTabIndex, added pass-throughs to selected tab, panelsPublisher, multi-tab session snapshot/restore, and SessionWorkspaceTabSnapshot.
Tab Manager & Notifications
Sources/TabManager.swift
Switched observations to panelsPublisher, added workspace-tab APIs (selectNext/Previous, createWorkspaceTabInSelectedWorkspace, closeSelectedWorkspaceTab), extended session fingerprinting, and added commandPaletteRenameWorkspaceTabRequested notification constant.
Command‑Palette & App Integration
Sources/ContentView.swift, Sources/AppDelegate.swift
Added command-palette workspace-tab rename flow, new rename request notification, shortcut routing for workspace-tab actions, and helpers to open/drive rename input.
Keyboard Shortcuts & Menus
Sources/KeyboardShortcutSettings.swift, Sources/cmuxApp.swift
Added new keyboard actions and defaults for workspace-tab navigation/actions, persisted @AppStorage shortcut data, and wired configurable shortcuts into macOS Commands UI.
Workspace Rendering & Interaction
Sources/WorkspaceContentView.swift
Render per-tab BonsplitViews in a stacked ZStack (one instantiated view per tab, visibility/hit-testing per selection), updated drop/focus resolution to per-tab maps, and integrated the tab bar into layout.
Tab Bar UI
Sources/WorkspaceTabBarView.swift
New WorkspaceTabBarView and WorkspaceTabItemView SwiftUI views: horizontal tab bar shown only when >1 tab, per-tab title/close controls, new-tab button, and tooltips.
App Delegate / Shortcuts Wiring
Sources/AppDelegate.swift
Added helper to request command-palette rename for workspace tabs and extended shortcut handling to route workspace-tab actions to TabManager/command palette.
Tests
cmuxTests/SessionPersistenceTests.swift
Added tests validating legacy snapshot decoding (absence of new keys) and encoding/decoding round-trip for multi-tab snapshots (tab count, selected index, customTitle preservation).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ContentView
    participant TabManager
    participant Workspace
    participant WorkspaceTab
    User->>ContentView: Click "New Tab" / press shortcut
    ContentView->>TabManager: createWorkspaceTabInSelectedWorkspace()
    TabManager->>Workspace: createWorkspaceTab()
    Workspace->>WorkspaceTab: init(bonsplitController)
    Workspace->>Workspace: append workspaceTabs, syncActivePanels()
    Workspace-->>ContentView: UI updates (workspace.panelsPublisher)
    User->>ContentView: Select tab
    ContentView->>Workspace: selectWorkspaceTab(at:)
    Workspace->>Workspace: set selectedWorkspaceTabIndex, syncActivePanels()
Loading
sequenceDiagram
    participant User
    participant AppDelegate
    participant NotificationCenter
    participant ContentView
    participant Workspace
    participant WorkspaceTab
    User->>AppDelegate: Press "rename workspace tab" shortcut
    AppDelegate->>NotificationCenter: post commandPaletteRenameWorkspaceTabRequested
    NotificationCenter->>ContentView: notification received
    ContentView->>ContentView: openCommandPaletteRenameWorkspaceTabInput()
    ContentView->>Workspace: beginRenameWorkspaceTabFlow()
    Workspace->>WorkspaceTab: set customTitle on confirm
    WorkspaceTab-->>ContentView: published changes -> UI updates
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Poem

🐰 I hopped in code with nimble paws,

tabs multiplied without a pause,
Views stacked quiet, each with its key,
Snapshots snug as a memory tree,
Hoppity—tabs, hooray for the cause!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main feature: adding a nested workspace tab layer, which is the primary change across all modified files.
Description check ✅ Passed The PR description provides comprehensive details about the feature, testing, and implementation but is missing the Demo Video section with a video URL or attachment.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 27, 2026

Greptile Summary

This PR adds workspace tabs — a nested tab layer sitting between the workspace sidebar and the split-pane layer — so each workspace can host multiple independent layouts. The implementation is large (~1400 lines net) and well-executed: state is cleanly extracted into a new WorkspaceTab model, Workspace exposes computed pass-throughs for backward compatibility, and the ZStack rendering approach (mirroring the existing workspace-level pattern in ContentView) prevents AppKit portal ghosting on tab switch.\n\nKey strengths:\n- All 11 BonsplitDelegate methods resolve the owning tab via controller identity (===), preventing cross-tab state corruption.\n- syncActivePanels() / panelsPublisher keeps all $panels subscribers connected through tab switches without requiring every callsite to be updated.\n- Session persistence is backward-compatible: single-tab workspaces write nil for the new fields and restore identically.\n- Close confirmation re-resolves the tab by ID after the async dialog, handling any reordering that occurred while the sheet was open.\n- workspaceTabObjectWillChangeCancellables is correctly cleaned up in forceCloseWorkspaceTab, so closed tabs don't leak.\n\nThree points to consider before merge:\n1. sessionWorkspaceTabSnapshot temporarily mutates @Published selectedWorkspaceTabIndex to piggyback on pass-throughs — correct but fires spurious objectWillChange per tab during every autosave cycle (details in inline comment).\n2. WorkspaceTabBarView renders items in a plain HStack with no horizontal scroll — tabs overflow silently once there are enough of them.\n3. In minimal mode (hidden titlebar), the ignoresSafeArea(.container, edges: .top) modifier was moved to the outer VStack that now includes the tab bar, which may push the bar behind window chrome when it's visible.

Confidence Score: 4/5

Safe to merge; no data-loss or correctness bugs found. The three flagged items are all cosmetic/UX-level P2s.

Core correctness is solid: BonsplitDelegate ownership, publisher forwarding, session persistence, and close-confirmation async safety are all handled correctly. The ignoresSafeArea placement in minimal mode is the one concern that could produce a visible layout regression (tab bar appearing under the titlebar), but only when 2+ workspace tabs are open in minimal mode — a relatively narrow scenario. The other two comments are quality-of-life improvements rather than blockers. Prior concerns from the thread (UUID sort, ?? UUID() allocation) were addressed in this version. Score of 4 reflects one targeted fix recommended before shipping.

Sources/WorkspaceContentView.swift (ignoresSafeArea scope in minimal mode), Sources/WorkspaceTabBarView.swift (tab bar overflow)

Important Files Changed

Filename Overview
Sources/WorkspaceTab.swift New model extracting all per-tab state from Workspace; well-structured with correct @published properties, weak back-reference, and descriptive derivedTitle logic preferring focused panel over UUID-sorted fallback.
Sources/Workspace.swift Core refactor replacing stored fields with computed pass-throughs to activeWorkspaceTab; session snapshot/restore logic is sound, BonsplitDelegate methods correctly resolve owning tab, but sessionWorkspaceTabSnapshot mutates @published selectedWorkspaceTabIndex to drive pass-throughs, firing spurious objectWillChange notifications during autosave.
Sources/WorkspaceTabBarView.swift Clean SwiftUI tab bar with auto-hide logic, opacity-toggle close button (no layout shift on hover), and correct @ObservedObject binding; no horizontal scrolling means overflow is silently clipped with many tabs.
Sources/WorkspaceContentView.swift Replaces single BonsplitView with a ZStack of per-tab BonsplitViews; correctly gates interactivity, portalPriority, and file-drop wiring per tab; fixes retain cycle in file-drop closure with [weak workspace, weak wsTab].
Sources/SessionPersistence.swift Backward-compatible schema addition: optional workspaceTabs and selectedWorkspaceTabIndex fields on SessionWorkspaceSnapshot; new SessionWorkspaceTabSnapshot struct is clean and correctly Codable.
Sources/TabManager.swift Correctly updates all .$panels subscriptions to panelsPublisher; adds thin forwarding methods for workspace tab navigation; autosave fingerprint now includes tab count and per-tab panel counts.
Sources/KeyboardShortcutSettings.swift Adds 6 new shortcut actions for workspace tab operations; moves closeOtherTabsInPane from hardcoded ⌥⌘T to a configurable shortcut (⌥⌘X default) to free ⌥⌘T for new workspace tab.
Sources/AppDelegate.swift Migrates all tab.$panels / workspace.$panels subscriptions to panelsPublisher; adds keyboard shortcut handlers for the 5 new workspace-tab actions; adds rename-workspace-tab command palette trigger.
Sources/ContentView.swift Adds workspace-tab rename flow, 5 command palette contributions with correct when guards (close/next/prev hidden with 1 tab), and correct workspacePanelsCancellable migration.
Sources/cmuxApp.swift Adds @AppStorage observers and menu items for 5 workspace-tab shortcuts; migrates closeOtherTabsInPane menu item to use dynamic shortcut via splitCommandButton.
cmuxTests/SessionPersistenceTests.swift Two well-targeted tests: verifies legacy snapshots decode with nil tabs (backward compat) and that multi-tab round-trip preserves count, selected index, and custom titles.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Workspace["Workspace (@MainActor, ObservableObject)"]
        WT["workspaceTabs: [WorkspaceTab]"]
        IDX["selectedWorkspaceTabIndex: Int @Published"]
        AP["activePanels: [UUID:Panel] @Published"]
        SYNC["syncActivePanels()"]
        PT["Computed pass-throughs\nbonsplitController, panels,\npanelDirectories, etc."]
    end

    subgraph WorkspaceTab1["WorkspaceTab (selected)"]
        BC1["BonsplitController"]
        P1["panels: [UUID:Panel] @Published"]
    end

    subgraph WorkspaceTab2["WorkspaceTab (background)"]
        BC2["BonsplitController (isInteractive=false)"]
        P2["panels: [UUID:Panel] @Published"]
    end

    IDX -->|"resolves via activeWorkspaceTab"| PT
    PT -->|"reads/writes"| WorkspaceTab1
    SYNC -->|"subscribes to tab.$panels"| P1
    P1 -->|"mirrors into"| AP
    AP -->|"panelsPublisher = $activePanels"| Outside["TabManager / AppDelegate\nContentView subscribers"]

    IDX -->|"selectWorkspaceTab(at:)\ncalls syncActivePanels()"| SYNC

    subgraph UI["WorkspaceContentView"]
        ZS["ZStack\nForEach workspaceTabs"]
        BSV1["BonsplitView(controller: tab1.bc)\nopacity=1, interactive=true"]
        BSV2["BonsplitView(controller: tab2.bc)\nopacity=0, interactive=false"]
    end

    BC1 --> BSV1
    BC2 --> BSV2
    BSV1 & BSV2 --> ZS

    subgraph TabBar["WorkspaceTabBarView"]
        TB["HStack of WorkspaceTabItemView\nauto-hides if count <= 1"]
    end

    Workspace -->|"@ObservedObject"| TabBar
    Workspace -->|"isWorkspaceInputActive\nselectedWorkspaceTabIndex"| UI
Loading

Reviews (2): Last reviewed commit: "fix: address code review bot feedback on..." | Re-trigger Greptile

Comment thread Sources/WorkspaceTabBarView.swift Outdated
Comment on lines +72 to +83
if !basename.isEmpty {
return basename
}
}
}

// Try panel titles
for panelId in tab.panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) {
if let title = tab.panelTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty {
return title
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Arbitrary UUID-sort order for tab title derivation

tab.panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) sorts by UUID string, which is effectively random from a user-visible perspective. This means the tab title (directory basename or panel title) is derived from whichever panel happens to sort first alphabetically — not the focused panel, not the first created panel, just a random one. In practice this can look like the tab title flickers to a different panel's name whenever panels are added or removed.

A more intuitive approach would be to prefer the focused panel's directory/title, falling back to the first-opened panel's. WorkspaceTab already tracks focusedPanelId for exactly this purpose:

// Prefer the focused panel's directory/title
if let focusedId = tab.focusedPanelId {
    if let dir = tab.panelDirectories[focusedId], !dir.isEmpty {
        let base = (dir as NSString).lastPathComponent
        if !base.isEmpty { return base }
    }
    if let title = tab.panelTitles[focusedId]?.trimmingCharacters(in: .whitespacesAndNewlines),
       !title.isEmpty {
        return title
    }
}
// Fall back to any panel (stable arbitrary order is fine as a last resort)
for panelId in tab.panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) { ... }

BonsplitView(controller: wsTab.bonsplitController) { tab, paneId in
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = wsTab.panels[wsTab.surfaceIdToPanelId[tab.id] ?? UUID()] {
let isFocused = isWorkspaceInputActive && isSelectedTab && workspace.focusedPanelId == panel.id
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ?? UUID() allocates a throwaway UUID on every render

wsTab.panels[wsTab.surfaceIdToPanelId[tab.id] ?? UUID()] creates a fresh UUID() object on every call — once per panel per render frame — just so the outer if let can evaluate to nil. The allocation is harmless but it's an unusual pattern that can confuse readers into thinking the UUID() is significant. Use two-level optional chaining instead:

Suggested change
let isFocused = isWorkspaceInputActive && isSelectedTab && workspace.focusedPanelId == panel.id
if let surfacePanelId = wsTab.surfaceIdToPanelId[tab.id],
let panel = wsTab.panels[surfacePanelId] {

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:459">
P2: createBareWorkspaceTab doesn't close Bonsplit's default "Welcome" tab, so restored tabs can retain an extra placeholder tab that restoreWorkspaceTabFromSnapshot never removes.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/Workspace.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Sources/WorkspaceContentView.swift (1)

386-412: ⚠️ Potential issue | 🟠 Major

Sync notification badges for all workspace tabs, not just the selected one.

syncBonsplitNotificationBadges() currently updates badges only for workspace.bonsplitController (the selected tab). Since all tabs remain alive in the ZStack, non-selected tabs' badge states become stale when notifications change or manual unread status updates.

Iterate workspace.workspaceTabs to sync badges across all tab controllers, similar to the pattern already used for setting isInteractive and onFileDrop per tab.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceContentView.swift` around lines 386 - 412,
syncBonsplitNotificationBadges only updates tabs from
workspace.bonsplitController (the selected controller), leaving other
controllers' tabs stale; change the function to iterate all controllers via
workspace.workspaceTabs and run the same badge/pin/kind logic for each tab
there. Locate syncBonsplitNotificationBadges and replace the outer loop over
workspace.bonsplitController.allPaneIds with iteration over
workspace.workspaceTabs (or the collection used elsewhere for
isInteractive/onFileDrop), compute panelId using
workspace.panelIdFromSurfaceId(tab.id), expectedKind via
workspace.panelKind(panelId:), expectedPinned via workspace.isPanelPinned(_),
and shouldShow using
notificationStore.hasVisibleNotificationIndicator(forTabId:workspace.id,
surfaceId:) || workspace.manualUnreadPanelIds; call bonsplitController.updateTab
(or the specific controller's updateTab) for each tab to apply kind,
showsNotificationBadge and isPinned updates.
Sources/Workspace.swift (1)

10372-10403: ⚠️ Potential issue | 🟠 Major

This confirmation path still consults the selected tab, not owningTab.

panelId comes from owningTab, but terminalPanel(for:) and panelNeedsConfirmClose(...) both read through activeWorkspaceTab. If a non-selected controller fires this callback, the guard falls through and a running terminal can close without confirmation. Use tab-scoped state here instead of the active-tab helpers.

Suggested direction
-        guard let panelId = owningTab.surfaceIdToPanelId[tab.id],
-              let terminalPanel = terminalPanel(for: panelId) else {
+        guard let panelId = owningTab.surfaceIdToPanelId[tab.id],
+              let terminalPanel = owningTab.panels[panelId] as? TerminalPanel else {
             stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
             recordPostCloseSelection()
             return true
         }

-        if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
+        if Self.resolveCloseConfirmation(
+            shellActivityState: owningTab.panelShellActivityStates[panelId],
+            fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()
+        ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 10372 - 10403, The confirmation branch
incorrectly uses helpers that consult the selected/active workspace tab (e.g.,
terminalPanel(for:) and panelNeedsConfirmClose(...)) instead of the caller's
tab-scoped state, so a non-selected controller can bypass confirmation; update
the code to resolve the terminal panel and confirmation check from owningTab's
scope: use the panelId you already pulled from
owningTab.surfaceIdToPanelId[tab.id] to look up the terminal panel via an
owningTab-scoped lookup (rather than terminalPanel(for:)) and call a
confirmation check that uses owningTab state (or call
terminalPanel.needsConfirmClose() on the panel instance obtained from owningTab)
so panelNeedsConfirmClose(...) does not consult activeWorkspaceTab; keep the
existing flow of inserting into owningTab.pendingCloseConfirmTabIds, dispatching
the async Task, and using owningTab.forceCloseTabIds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 9415-9418: The newWorkspaceTab shortcut is shadowing the legacy
⌥⌘T handler; update either KeyboardShortcutSettings.shortcut(for:
.newWorkspaceTab) to a different default or remove/remap the new handler so
AppDelegate.handleCustomShortcut(event:) can still route ⌥⌘T to
closeOtherTabsInFocusedPaneWithConfirmation(); locate the matchShortcut(...)
check around matchShortcut(event: event, shortcut:
KeyboardShortcutSettings.shortcut(for: .newWorkspaceTab)) and the
tabManager?.createWorkspaceTabInSelectedWorkspace() call and change the default
binding or delete/adjust that branch so the legacy
closeOtherTabsInFocusedPaneWithConfirmation() path remains reachable.

In `@Sources/cmuxApp.swift`:
- Around line 790-800: The default shortcut for
KeyboardShortcutSettings.Action.newWorkspaceTab (currently ⌥⌘T) conflicts with
other bindings; change its defaultShortcut to a non-conflicting combo (e.g.
Option-Command-N) by updating
KeyboardShortcutSettings.Action.newWorkspaceTab.defaultShortcut, and ensure the
splitCommandButton usage in cmuxApp.swift (the "New Workspace Tab" call) and any
hard-coded handling in AppDelegate.handleCustomShortcut are consistent with the
new default so the ambiguous ⌥⌘T routing no longer masks this action.

In `@Sources/ContentView.swift`:
- Around line 5307-5310: workspaceTabSubtitle is using
CommandPaletteContextKeys.workspaceName (the parent workspace) instead of the
active tab's title, so workspace-targeting commands become ambiguous; change
workspaceTabSubtitle to read the active workspace-tab name from the command
palette context (e.g., a key like CommandPaletteContextKeys.workspaceTabName or
selectedWorkspaceTabTitle) and use that as the subtitle (falling back to the
workspace name or your existing "Workspace" fallback if the tab name is
missing), and apply the same replacement for the other relevant helpers in the
6004-6053 region so Close/Next/Previous/Rename Workspace Tab display the active
tab name rather than the workspace name.
- Around line 7362-7374: The rename flow for workspace tabs currently seeds the
input with wsTab.customTitle ?? "" which leaves untitled tabs empty; change
beginRenameWorkspaceTabFlow to seed using the tab's displayed title (use the
same property other flows use, e.g. wsTab.title or a displayTitle getter)
instead of wsTab.customTitle so the field pre-fills the current visible name;
update the CommandPaletteRenameTarget initialization to pass that displayed
title as currentName and leave the rest (workspace.selectedWorkspaceTab,
startRenameFlow) unchanged.

In `@Sources/KeyboardShortcutSettings.swift`:
- Around line 205-206: The enum case newWorkspaceTab currently returns a
hard-coded StoredShortcut (key: "t", command: true, option: true) which
conflicts with the existing hard-coded ⌥⌘T handler for
closeOtherTabsInFocusedPaneWithConfirmation; update the shortcut wiring so the
handlers don't shadow each other by either assigning newWorkspaceTab a different
default StoredShortcut (change the StoredShortcut returned in newWorkspaceTab)
or remove the hard-coded ⌥⌘T handling and instead route
closeOtherTabsInFocusedPaneWithConfirmation through the settings system; locate
the StoredShortcut return in the newWorkspaceTab case and the
closeOtherTabsInFocusedPaneWithConfirmation handler and make the change
consistently so only one handler matches ⌥⌘T at runtime.

In `@Sources/TabManager.swift`:
- Around line 3255-3275: The sessionAutosaveFingerprint() currently only
includes active workspace pass-throughs so mutations to non-selected workspace
tabs (created/closed via methods like createWorkspaceTabInSelectedWorkspace,
closeSelectedWorkspaceTab, selectNextWorkspaceTab, selectPreviousWorkspaceTab)
can change the persisted session without updating the fingerprint; update
sessionAutosaveFingerprint() to incorporate the nested workspace-tab state (each
Workspace's tabs collection and the selected tab index) or simply derive the
fingerprint from the full session snapshot used for persistence (e.g. compute
the hash over session snapshot data rather than only active pass-throughs) so
tab-bar changes in any workspace will flip the dirty check consistently.

In `@Sources/Workspace.swift`:
- Around line 302-306: The snapshot created in make/save path omits tab title
data so renamed workspace tabs don't persist; update SessionWorkspaceTabSnapshot
to include title and customTitle (or similar properties), then update the code
that constructs the snapshot (the return of SessionWorkspaceTabSnapshot(...) in
Workspace.swift) to pass the current tab's title/custom title values, and ensure
corresponding decoding/restore logic uses these new fields when restoring tabs
so names round-trip correctly.
- Around line 5956-6004: closeWorkspaceTab currently force-closes panels by
calling panel.close() and manually shredding tab dictionaries, bypassing the
centralized close/confirmation pipeline and Bonsplit callbacks (e.g.,
untrackRemoteTerminalSurface, notification clearing, closed-browser staging).
Instead, iterate the tab's panel IDs and invoke the workspace/registry-level
panel close function that runs the full close pipeline (the same code path used
elsewhere — e.g., the Bonsplit/Workspace method that handles surface/panel
closing and confirmations), so per-surface side-effects and confirmations run;
remove the ad-hoc dictionary removals tied to panel lifecycle (leaving only
tab-level cleanup after the pipeline completes); ensure
PortScanner.shared.unregisterPanel and workspaceTabObjectWillChangeCancellables
are updated after the centralized close completes, then call syncActivePanels()
and reassertWorkspaceTabFocus() as currently done.
- Around line 397-405: After setting selectedWorkspaceTabIndex you must reapply
the focus-derived workspace mirrors (currentDirectory, gitBranch, pullRequest,
focus) for the newly selected tab so they reflect the actual selected tab rather
than the last-restored tab; update the block that currently only calls
syncActivePanels() to also invoke the same mirror-refresh logic used in
restoreWorkspaceTabFromSnapshot(...) (or extract that mirror-update code into a
helper and call it here) so that selectedWorkspaceTabIndex,
restoreWorkspaceTabFromSnapshot, syncActivePanels, and the workspace mirror
properties are all kept in sync.

In `@Sources/WorkspaceContentView.swift`:
- Around line 259-265: The closure assigned to bonsplitController.onFileDrop
captures wsTab strongly causing a retain cycle; change the capture list to
capture wsTab weakly (e.g. [weak wsTab, weak workspace]) and update the guard
statements to unwrap the weak wsTab before accessing surfaceIdToPanelId and
panels, then call panel.hostedView.handleDroppedURLs(urls) on the unwrapped
wsTab's panel; ensure TerminalPanel casting and selectedTab lookup remain the
same but operate on the unwrapped weak wsTab to break the cycle.

In `@Sources/WorkspaceTabBarView.swift`:
- Line 86: Replace the hard-coded fallback title "New Tab" in
WorkspaceTabBarView with a localized string using the required API; locate the
computed property or method that currently returns "New Tab" and change it to
something like String(localized: "workspaceTabBar.newTab", defaultValue: "New
Tab") so the UI text is localized (use the key naming scheme your project uses).

---

Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 10372-10403: The confirmation branch incorrectly uses helpers that
consult the selected/active workspace tab (e.g., terminalPanel(for:) and
panelNeedsConfirmClose(...)) instead of the caller's tab-scoped state, so a
non-selected controller can bypass confirmation; update the code to resolve the
terminal panel and confirmation check from owningTab's scope: use the panelId
you already pulled from owningTab.surfaceIdToPanelId[tab.id] to look up the
terminal panel via an owningTab-scoped lookup (rather than terminalPanel(for:))
and call a confirmation check that uses owningTab state (or call
terminalPanel.needsConfirmClose() on the panel instance obtained from owningTab)
so panelNeedsConfirmClose(...) does not consult activeWorkspaceTab; keep the
existing flow of inserting into owningTab.pendingCloseConfirmTabIds, dispatching
the async Task, and using owningTab.forceCloseTabIds.

In `@Sources/WorkspaceContentView.swift`:
- Around line 386-412: syncBonsplitNotificationBadges only updates tabs from
workspace.bonsplitController (the selected controller), leaving other
controllers' tabs stale; change the function to iterate all controllers via
workspace.workspaceTabs and run the same badge/pin/kind logic for each tab
there. Locate syncBonsplitNotificationBadges and replace the outer loop over
workspace.bonsplitController.allPaneIds with iteration over
workspace.workspaceTabs (or the collection used elsewhere for
isInteractive/onFileDrop), compute panelId using
workspace.panelIdFromSurfaceId(tab.id), expectedKind via
workspace.panelKind(panelId:), expectedPinned via workspace.isPanelPinned(_),
and shouldShow using
notificationStore.hasVisibleNotificationIndicator(forTabId:workspace.id,
surfaceId:) || workspace.manualUnreadPanelIds; call bonsplitController.updateTab
(or the specific controller's updateTab) for each tab to apply kind,
showsNotificationBadge and isPinned updates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7d266356-e3eb-4e67-b6c0-dc773d0214ad

📥 Commits

Reviewing files that changed from the base of the PR and between 5c7cafe and d590056.

📒 Files selected for processing (12)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceContentView.swift
  • Sources/WorkspaceTab.swift
  • Sources/WorkspaceTabBarView.swift
  • Sources/cmuxApp.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/AppDelegate.swift
Comment thread Sources/cmuxApp.swift
Comment on lines +790 to +800
splitCommandButton(title: String(localized: "menu.view.nextWorkspaceTab", defaultValue: "Next Workspace Tab"), shortcut: nextWorkspaceTabMenuShortcut) {
activeTabManager.selectNextWorkspaceTab()
}

splitCommandButton(title: String(localized: "menu.view.previousWorkspaceTab", defaultValue: "Previous Workspace Tab"), shortcut: prevWorkspaceTabMenuShortcut) {
activeTabManager.selectPreviousWorkspaceTab()
}

splitCommandButton(title: String(localized: "menu.view.newWorkspaceTab", defaultValue: "New Workspace Tab"), shortcut: newWorkspaceTabMenuShortcut) {
activeTabManager.createWorkspaceTabInSelectedWorkspace()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Check new workspace-tab shortcut default:"
rg -n --type=swift -C3 'case\s+newWorkspaceTab|newWorkspaceTab.*defaultShortcut|StoredShortcut\(.*"t".*\.option' Sources/KeyboardShortcutSettings.swift

echo
echo "2) Check existing Cmd+Option+T bindings in app/menu routing:"
rg -n --type=swift -C3 'keyboardShortcut\("t", modifiers:\s*\[\.command,\s*\.option\]\)|closeOtherTabsInFocusedPaneWithConfirmation|handleCustomShortcut' Sources/cmuxApp.swift Sources/AppDelegate.swift

Repository: manaflow-ai/cmux

Length of output: 6864


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for newWorkspaceTab defaultShortcut definition ==="
rg -n --type=swift -A10 'case newWorkspaceTab' Sources/KeyboardShortcutSettings.swift

echo
echo "=== Check around lines 790-800 in cmuxApp.swift for shortcut binding ==="
sed -n '780,810p' Sources/cmuxApp.swift | cat -n

echo
echo "=== Search for newWorkspaceTab in handleCustomShortcut ==="
rg -n --type=swift -B2 -A5 'newWorkspaceTab|createWorkspaceTabInSelectedWorkspace' Sources/AppDelegate.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 3065


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Find newWorkspaceTabMenuShortcut variable definition ==="
rg -n --type=swift 'let.*newWorkspaceTabMenuShortcut|var.*newWorkspaceTabMenuShortcut' Sources/

echo
echo "=== Find default shortcut mappings in KeyboardShortcutSettings ==="
rg -n --type=swift 'defaultShortcut|case newWorkspaceTab' Sources/KeyboardShortcutSettings.swift | head -30

echo
echo "=== Search for StoredShortcut or defaultShortcut patterns ==="
rg -n --type=swift 'StoredShortcut.*\.option.*\.t|\"t\".*option|newWorkspaceTab.*default' Sources/KeyboardShortcutSettings.swift

Repository: manaflow-ai/cmux

Length of output: 814


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Show newWorkspaceTabMenuShortcut definition in cmuxApp.swift ==="
sed -n '1008,1015p' Sources/cmuxApp.swift | cat -n

echo
echo "=== Show defaultShortcut property and newWorkspaceTab case in KeyboardShortcutSettings ==="
sed -n '141,200p' Sources/KeyboardShortcutSettings.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 5129


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Continue reading defaultShortcut cases to find newWorkspaceTab ==="
sed -n '141,250p' Sources/KeyboardShortcutSettings.swift | cat -n

echo
echo "=== Or search specifically for newWorkspaceTab case ==="
rg -n --type=swift -A1 'case newWorkspaceTab' Sources/KeyboardShortcutSettings.swift

Repository: manaflow-ai/cmux

Length of output: 7537


Resolve default shortcut collision for "New Workspace Tab".

KeyboardShortcutSettings.Action.newWorkspaceTab.defaultShortcut is ⌥⌘T, which conflicts with the "Close Other Tabs in Pane" shortcut at Sources/cmuxApp.swift:645 and the hard-coded ⌥⌘T routing in AppDelegate.handleCustomShortcut() (line 9448). This creates ambiguous behavior where one action masks the other.

Assign a non-conflicting default to newWorkspaceTab (similar to how toggleTextBoxInput uses ⌥⌘B to avoid this same collision).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 790 - 800, The default shortcut for
KeyboardShortcutSettings.Action.newWorkspaceTab (currently ⌥⌘T) conflicts with
other bindings; change its defaultShortcut to a non-conflicting combo (e.g.
Option-Command-N) by updating
KeyboardShortcutSettings.Action.newWorkspaceTab.defaultShortcut, and ensure the
splitCommandButton usage in cmuxApp.swift (the "New Workspace Tab" call) and any
hard-coded handling in AppDelegate.handleCustomShortcut are consistent with the
new default so the ambiguous ⌥⌘T routing no longer masks this action.

Comment thread Sources/ContentView.swift
Comment on lines +5307 to +5310
func workspaceTabSubtitle(_ context: CommandPaletteContextSnapshot) -> String {
let name = context.string(CommandPaletteContextKeys.workspaceName) ?? String(localized: "commandPalette.subtitle.workspaceFallback", defaultValue: "Workspace")
return String(localized: "commandPalette.subtitle.workspaceTabWithName", defaultValue: "Workspace Tab • \(name)")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make workspace-tab commands identify the active tab.

workspaceTabSubtitle currently renders the parent workspace name, so Close/Next/Previous/Rename Workspace Tab all describe the workspace instead of the selected workspace tab. In a multi-tab workspace that makes the command target ambiguous.

Also applies to: 6004-6053

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 5307 - 5310, workspaceTabSubtitle is
using CommandPaletteContextKeys.workspaceName (the parent workspace) instead of
the active tab's title, so workspace-targeting commands become ambiguous; change
workspaceTabSubtitle to read the active workspace-tab name from the command
palette context (e.g., a key like CommandPaletteContextKeys.workspaceTabName or
selectedWorkspaceTabTitle) and use that as the subtitle (falling back to the
workspace name or your existing "Workspace" fallback if the tab name is
missing), and apply the same replacement for the other relevant helpers in the
6004-6053 region so Close/Next/Previous/Rename Workspace Tab display the active
tab name rather than the workspace name.

Comment thread Sources/ContentView.swift
Comment thread Sources/KeyboardShortcutSettings.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
Comment on lines +5956 to +6004
func closeWorkspaceTab(at index: Int) {
guard workspaceTabs.count > 1, workspaceTabs.indices.contains(index) else { return }

let tab = workspaceTabs[index]

// Tear down all panels in the tab being closed.
let panelEntries = Array(tab.panels)
for (panelId, panel) in panelEntries {
tab.panelSubscriptions.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
panel.close()
}
tab.panels.removeAll(keepingCapacity: false)
tab.surfaceIdToPanelId.removeAll(keepingCapacity: false)
tab.panelSubscriptions.removeAll(keepingCapacity: false)
tab.panelDirectories.removeAll(keepingCapacity: false)
tab.panelTitles.removeAll(keepingCapacity: false)
tab.panelCustomTitles.removeAll(keepingCapacity: false)
tab.panelGitBranches.removeAll(keepingCapacity: false)
tab.panelPullRequests.removeAll(keepingCapacity: false)
tab.pinnedPanelIds.removeAll()
tab.manualUnreadPanelIds.removeAll()
tab.manualUnreadMarkedAt.removeAll(keepingCapacity: false)
tab.surfaceListeningPorts.removeAll(keepingCapacity: false)
tab.surfaceTTYNames.removeAll(keepingCapacity: false)
tab.panelShellActivityStates.removeAll(keepingCapacity: false)
tab.restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
tab.terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
tab.lastTerminalConfigInheritancePanelId = nil
tab.lastTerminalConfigInheritanceFontPoints = nil

// Remove the objectWillChange forwarding subscription.
workspaceTabObjectWillChangeCancellables.removeValue(forKey: tab.id)

// Remove from the array.
workspaceTabs.remove(at: index)

// Adjust selected index: if we closed the selected tab or one before it,
// keep the selection valid.
if selectedWorkspaceTabIndex >= workspaceTabs.count {
selectedWorkspaceTabIndex = workspaceTabs.count - 1
} else if index < selectedWorkspaceTabIndex {
selectedWorkspaceTabIndex -= 1
}
// If we closed the selected tab itself, the index now points to the
// tab that slid into its position (or the one before it if it was last).
// Either way, sync the panels publisher and reassert focus.
syncActivePanels()
reassertWorkspaceTabFocus()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

closeWorkspaceTab is bypassing the normal close pipeline.

This bulk teardown hard-closes every panel with panel.close() and manual dictionary cleanup, so terminals in that workspace tab never go through the existing confirmation path. It also skips the per-surface side effects already centralized in the Bonsplit callbacks (untrackRemoteTerminalSurface, notification clearing, closed-browser staging, etc.), which leaves workspace-level state stale after ⌥⌘W.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 5956 - 6004, closeWorkspaceTab
currently force-closes panels by calling panel.close() and manually shredding
tab dictionaries, bypassing the centralized close/confirmation pipeline and
Bonsplit callbacks (e.g., untrackRemoteTerminalSurface, notification clearing,
closed-browser staging). Instead, iterate the tab's panel IDs and invoke the
workspace/registry-level panel close function that runs the full close pipeline
(the same code path used elsewhere — e.g., the Bonsplit/Workspace method that
handles surface/panel closing and confirmations), so per-surface side-effects
and confirmations run; remove the ad-hoc dictionary removals tied to panel
lifecycle (leaving only tab-level cleanup after the pipeline completes); ensure
PortScanner.shared.unregisterPanel and workspaceTabObjectWillChangeCancellables
are updated after the centralized close completes, then call syncActivePanels()
and reassertWorkspaceTabFocus() as currently done.

Comment thread Sources/WorkspaceContentView.swift Outdated
Comment thread Sources/WorkspaceTabBarView.swift Outdated
@oobagi oobagi marked this pull request as draft March 27, 2026 04:27
Fixes all critical, major, and minor issues from CodeRabbit, Greptile,
and Cubic reviews:

Critical:
- panels pass-through now iterates all workspace tabs for workspace-wide
  operations (needsConfirmClose, teardownAllPanels, hidePortalViews,
  shouldCloseWorkspaceOnLastSurface)
- closeWorkspaceTab now checks for running processes and shows
  confirmation dialog before closing, with full cleanup side effects
  (untrackRemoteTerminalSurface, clearNotifications)

Major:
- Resolved ⌥⌘T shortcut collision: "Close Other Tabs in Pane" moved to
  ⌥⌘X and is now user-configurable via KeyboardShortcutSettings
- Fixed retain cycle in onFileDrop closure (weak wsTab capture)
- Session autosave fingerprint now includes workspace tab count and
  selected index
- Custom tab titles now persist across session save/restore
- Bare-tab restore cleans up Bonsplit Welcome tabs

Minor:
- Tab title prefers focused panel's directory over UUID-sort
- Fallback "New Tab" string localized
- Rename flow prefills with derived title instead of empty string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@oobagi oobagi marked this pull request as ready for review March 27, 2026 05:01
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/ContentView.swift">

<violation number="1" location="Sources/ContentView.swift:5999">
P2: Workspace-tab command entries hardcode shortcut hints, but commandPaletteShortcutAction doesn’t map the new workspace-tab command IDs to KeyboardShortcutSettings.Action, so the palette won’t reflect user-customized shortcuts and will keep showing these defaults.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:5371">
P2: Workspace-level Bonsplit appearance/tooltip updates now affect only the active workspace tab controller, causing config drift across workspace tabs.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/ContentView.swift
commandId: "palette.newWorkspaceTab",
title: constant(String(localized: "command.newWorkspaceTab.title", defaultValue: "New Workspace Tab")),
subtitle: workspaceTabSubtitle,
shortcutHint: "⌥⌘T",
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Workspace-tab command entries hardcode shortcut hints, but commandPaletteShortcutAction doesn’t map the new workspace-tab command IDs to KeyboardShortcutSettings.Action, so the palette won’t reflect user-customized shortcuts and will keep showing these defaults.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ContentView.swift, line 5999:

<comment>Workspace-tab command entries hardcode shortcut hints, but commandPaletteShortcutAction doesn’t map the new workspace-tab command IDs to KeyboardShortcutSettings.Action, so the palette won’t reflect user-customized shortcuts and will keep showing these defaults.</comment>

<file context>
@@ -5960,6 +5989,69 @@ struct ContentView: View {
+                commandId: "palette.newWorkspaceTab",
+                title: constant(String(localized: "command.newWorkspaceTab.title", defaultValue: "New Workspace Tab")),
+                subtitle: workspaceTabSubtitle,
+                shortcutHint: "⌥⌘T",
+                keywords: ["new", "workspace", "tab", "create"],
+                when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
</file context>
Fix with Cubic

Comment thread Sources/Workspace.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (4)
Sources/ContentView.swift (1)

5241-5244: ⚠️ Potential issue | 🟡 Minor

Capture the active workspace-tab name in the palette context.

Lines 5241-5244 only record the tab count, and Line 5308 still reads workspaceName. That leaves the new Close/Next/Previous/Rename Workspace Tab entries labeled with the parent workspace instead of the selected workspace tab, which is ambiguous in multi-tab workspaces.

Suggested fix
         static let workspaceHasMultipleTabs = "workspace.hasMultipleTabs"
+        static let workspaceSelectedTabName = "workspace.selectedTabName"
@@
             snapshot.setBool(
                 CommandPaletteContextKeys.workspaceHasMultipleTabs,
                 workspace.workspaceTabs.count > 1
             )
+            snapshot.setString(
+                CommandPaletteContextKeys.workspaceSelectedTabName,
+                workspace.selectedWorkspaceTab.map {
+                    ($0.customTitle ?? $0.derivedTitle)
+                        .trimmingCharacters(in: .whitespacesAndNewlines)
+                }
+            )
@@
         func workspaceTabSubtitle(_ context: CommandPaletteContextSnapshot) -> String {
-            let name = context.string(CommandPaletteContextKeys.workspaceName) ?? String(localized: "commandPalette.subtitle.workspaceFallback", defaultValue: "Workspace")
+            let name = context.string(CommandPaletteContextKeys.workspaceSelectedTabName)
+                ?? context.string(CommandPaletteContextKeys.workspaceName)
+                ?? String(localized: "commandPalette.subtitle.workspaceFallback", defaultValue: "Workspace")
             return String(localized: "commandPalette.subtitle.workspaceTabWithName", defaultValue: "Workspace Tab • \(name)")
         }

Also applies to: 5307-5310

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 5241 - 5244, The palette context
currently only records tab count via
snapshot.setBool(CommandPaletteContextKeys.workspaceHasMultipleTabs,
workspace.workspaceTabs.count > 1) so commands still display the parent
workspaceName instead of the selected tab; update the context to also capture
the active workspace-tab name (e.g., set a new or existing key like
CommandPaletteContextKeys.workspaceTabName or similar) with the selected tab's
name (derived from workspace.workspaceTabs[workspace.activeTabIndex] or the
workspace.activeTab property) and ensure the same change is applied where
workspaceName is read (around the code that handles the
Close/Next/Previous/Rename Workspace Tab entries at the 5307-5310 area) so those
commands show the selected tab name instead of the parent workspace.
Sources/Workspace.swift (3)

5376-5389: ⚠️ Potential issue | 🔴 Critical

Selected-tab pass-through state is still leaking into non-selected contexts.

After panels and the panel metadata stores became pass-throughs to activeWorkspaceTab, several callers below still use them from workspace-wide or controller-scoped paths. In this file that means background tabs can miss remote proxy/status propagation, and delegate flows that resolve owningTab can still bounce back through selected-tab helpers like terminalPanel(for:), browserPanel(for:), or resolvedPanelTitle(...), reintroducing cross-tab corruption.

Also applies to: 5463-5563


6087-6102: ⚠️ Potential issue | 🟠 Major

Workspace-tab activation never refreshes focus-derived mirrors.

syncActivePanels() only swaps the publisher and reassertWorkspaceTabFocus() only nudges responder state. After tab selection, tab-close, and restore reuse this pair, currentDirectory, gitBranch, and pullRequest can stay pointed at the previously active workspace tab until some later Bonsplit callback happens.

💡 Reuse the existing tab-selection path here
     private func reassertWorkspaceTabFocus() {
         guard let tab = selectedWorkspaceTab else { return }

-        // Find the focused panel in the selected tab.
         guard let focusedPaneId = tab.bonsplitController.focusedPaneId,
-              let selectedBonsplitTab = tab.bonsplitController.selectedTab(inPane: focusedPaneId),
-              let panelId = tab.surfaceIdToPanelId[selectedBonsplitTab.id],
-              let panel = tab.panels[panelId] else { return }
-
-        panel.focus()
-
-        // For terminal panels, explicitly move AppKit first responder.
-        if let terminalPanel = panel as? TerminalPanel {
-            terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
-        }
+              let selectedBonsplitTab = tab.bonsplitController.selectedTab(inPane: focusedPaneId) else { return }
+
+        applyTabSelection(tabId: selectedBonsplitTab.id, inPane: focusedPaneId)
     }

Also applies to: 6104-6121

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 6087 - 6102, syncActivePanels()
replaces the activePanels publisher but doesn't refresh focus-derived mirrors
(currentDirectory, gitBranch, pullRequest); update it to re-use the existing
tab-selection path by calling the same focus-refresh routine used on tab
selection (e.g. invoke reassertWorkspaceTabFocus() or the shared tab-selection
handler) immediately after swapping tab and setting activePanels so
responder-derived mirrors are recomputed; ensure the same change is applied in
the sibling block noted (the duplicate at 6104-6121) so both paths keep mirror
state in sync when tabs are switched/closed/restored.

240-243: ⚠️ Potential issue | 🟠 Major

Persist renamed single workspace tabs too.

workspaceTabs is only emitted when workspaceTabs.count > 1, so a lone workspace tab's customTitle never reaches SessionWorkspaceTabSnapshot. The top-level customTitle is the workspace title, not the workspace-tab title, so renaming the only workspace tab still resets on restore.

💡 One way to preserve the legacy shape unless per-tab-only state exists
-        let tabSnapshots: [SessionWorkspaceTabSnapshot]? = workspaceTabs.count > 1
+        let shouldPersistWorkspaceTabs =
+            workspaceTabs.count > 1 ||
+            workspaceTabs.contains {
+                ($0.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "").isEmpty == false
+            }
+        let tabSnapshots: [SessionWorkspaceTabSnapshot]? = shouldPersistWorkspaceTabs
             ? workspaceTabs.map { sessionWorkspaceTabSnapshot(tab: $0, includeScrollback: includeScrollback) }
             : nil
...
-            selectedWorkspaceTabIndex: workspaceTabs.count > 1 ? selectedWorkspaceTabIndex : nil
+            selectedWorkspaceTabIndex: shouldPersistWorkspaceTabs ? selectedWorkspaceTabIndex : nil

Also applies to: 258-259

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 240 - 243, Currently tab snapshots are
only built when workspaceTabs.count > 1, which drops per-tab-only state (like a
lone tab's customTitle) for single-tab workspaces; update the logic so that you
call sessionWorkspaceTabSnapshot(tab:includeScrollback:) for the lone tab as
well whenever any tab contains per-tab-only state (e.g. customTitle) — i.e.
compute a boolean like hasPerTabState = workspaceTabs.contains { $0.customTitle
!= nil /* or other per-tab-only props */ } and then set tabSnapshots =
workspaceTabs.map { sessionWorkspaceTabSnapshot(tab: $0, includeScrollback:
includeScrollback) } if workspaceTabs.count > 1 || hasPerTabState, otherwise
nil.
🧹 Nitpick comments (5)
Sources/cmuxApp.swift (2)

168-173: Factor the new shortcut plumbing through one helper.

These new storage/accessor pairs all repeat the same decode/fallback shape. A tiny wrapper around decodeShortcut(from:fallback:) would reduce the copy/paste surface and make the next shortcut addition less error-prone.

♻️ Example simplification
+    private func menuShortcut(from data: Data, action: KeyboardShortcutSettings.Action) -> StoredShortcut {
+        decodeShortcut(from: data, fallback: action.defaultShortcut)
+    }
+
     private var nextWorkspaceTabMenuShortcut: StoredShortcut {
-        decodeShortcut(
-            from: nextWorkspaceTabShortcutData,
-            fallback: KeyboardShortcutSettings.Action.nextWorkspaceTab.defaultShortcut
-        )
+        menuShortcut(from: nextWorkspaceTabShortcutData, action: .nextWorkspaceTab)
     }

Also applies to: 994-1034

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 168 - 173, Multiple `@AppStorage` Data
properties repeat the same decode/fallback pattern; introduce a small helper
that wraps decodeShortcut(from:fallback:) and use it when initializing/reading
these shortcuts. Create a function (e.g. loadShortcut(fromData:fallback:) or a
computed property initializer) that takes the Data AppStorage value
(nextWorkspaceTabShortcutData, prevWorkspaceTabShortcutData,
newWorkspaceTabShortcutData, renameWorkspaceTabShortcutData,
closeWorkspaceTabShortcutData, closeOtherTabsInPaneShortcutData) and returns the
decoded KeyboardShortcut with the provided fallback, then replace the repeated
decode/fallback calls with calls to that helper (referencing
decodeShortcut(from:fallback:) inside it) to remove duplication.

790-808: Disable the workspace-tab navigation/close commands when only one tab exists.

Next, Previous, and Close Workspace Tab stay enabled here even though the rest of the feature suppresses those actions for single-tab workspaces. That leaves dead menu items at best, and it can expose a last-tab close path that the tab bar intentionally hides. Please gate these on the selected workspace’s tab count.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 790 - 808, The Next/Previous/Close
workspace tab commands should be disabled when the selected workspace has only
one tab; update the splitCommandButton invocations that call
activeTabManager.selectNextWorkspaceTab, selectPreviousWorkspaceTab, and
closeSelectedWorkspaceTab to compute a boolean like hasMultipleTabs =
(activeTabManager.selectedWorkspace?.tabs.count ?? 0) > 1 (or use an existing
helper on activeTabManager) and apply that to the button’s disabled state (e.g.,
.disabled(!hasMultipleTabs) or the equivalent API) so the commands are not
enabled for single-tab workspaces.
Sources/AppDelegate.swift (2)

4402-4412: Make the helper’s default source API-neutral.

The sibling requestCommandPalette* helpers default to api.*, but this one defaults to a shortcut-specific source. If another caller reuses it later, the provenance is wrong by default. I’d make the helper default api.commandPaletteRenameWorkspaceTab and pass "shortcut.renameWorkspaceTab" explicitly from the shortcut branch.

Suggested tweak
 func requestCommandPaletteRenameWorkspaceTab(
     preferredWindow: NSWindow? = nil,
-    source: String = "shortcut.renameWorkspaceTab"
+    source: String = "api.commandPaletteRenameWorkspaceTab"
 ) {
     postCommandPaletteRequest(
         name: .commandPaletteRenameWorkspaceTabRequested,
         preferredWindow: preferredWindow,
         source: source,
         markPending: true
     )
 }

 if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameWorkspaceTab)) {
     requestCommandPaletteRenameWorkspaceTab(
-        preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
+        preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
+        source: "shortcut.renameWorkspaceTab"
     )
     return true
 }

Also applies to: 9420-9423

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 4402 - 4412, The helper
requestCommandPaletteRenameWorkspaceTab has a shortcut-specific default for its
source parameter ("shortcut.renameWorkspaceTab") which makes provenance
incorrect for non-shortcut callers; change the default source to an API-neutral
value (e.g., "api.commandPaletteRenameWorkspaceTab") and update the
shortcut-specific caller to pass "shortcut.renameWorkspaceTab" explicitly when
invoking postCommandPaletteRequest; ensure the same change is applied to the
other duplicate helper occurrence (the other
requestCommandPaletteRenameWorkspaceTab definition) so both use the neutral
default and the shortcut path supplies the explicit shortcut source.

9404-9404: Fix the shortcut comment before it drifts further.

Line 9404 says Cmd+Shift+T, but .newWorkspaceTab defaults to ⌥⌘T in KeyboardShortcutSettings. I’d either correct the binding in the comment or make the comment generic.

Suggested tweak
-        // Workspace tab navigation: Cmd+Opt+] / Cmd+Opt+[ / Cmd+Shift+T
+        // Workspace tab navigation/management shortcuts.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` at line 9404, The comment "Workspace tab
navigation: Cmd+Opt+] / Cmd+Opt+[ / Cmd+Shift+T" is inconsistent with the actual
default binding for .newWorkspaceTab in KeyboardShortcutSettings (which is ⌥⌘T);
update the comment to match the real default or make it generic. Locate the
comment near the workspace tab navigation section in AppDelegate.swift and
either change "Cmd+Shift+T" to "Cmd+Opt+T" (or to "⌥⌘T") or replace the specific
binding with a generic phrase like "use your configured shortcut (see
KeyboardShortcutSettings.newWorkspaceTab)"; ensure you reference
.newWorkspaceTab/KeyboardShortcutSettings in the updated comment so it stays
accurate.
Sources/WorkspaceTab.swift (1)

82-153: This bookkeeping wants a couple of smaller substate types.

From Line 82 onward, WorkspaceTab is already carrying several distinct state machines: close confirmation, focus reconciliation, detach/attach transfer, and layout follow-up. Pulling those into dedicated nested structs/helpers would make the invariants easier to follow and reduce the risk of this extraction turning into another catch-all model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceTab.swift` around lines 82 - 153, WorkspaceTab currently
mixes multiple independent substates in-line (close confirmation, focus
reconciliation, detach/attach, layout follow-up) making invariants hard to
follow; extract these into small nested types (e.g., WorkspaceTab.CloseState,
WorkspaceTab.FocusReconcileState, WorkspaceTab.DetachState,
WorkspaceTab.LayoutFollowUpState) and move the related stored properties into
each respective struct (for example move pendingCloseConfirmTabIds,
forceCloseTabIds, explicitUserCloseTabIds, postCloseSelectTabId into CloseState;
isReconcilingFocusState, focusReconcileScheduled and related debug counters into
FocusReconcileState; detachingTabIds, pendingDetachedSurfaces,
activeDetachCloseTransactions into DetachState; layoutFollowUpObservers,
layoutFollowUpPanelsCancellable, layoutFollowUpTimeoutWorkItem,
layoutFollowUpReason, layoutFollowUpTerminalFocusPanelId,
layoutFollowUpBrowserPanelId, layoutFollowUpBrowserExitFocusPanelId,
layoutFollowUpNeedsGeometryPass, layoutFollowUpAttemptScheduled,
layoutFollowUpStalledAttemptCount, isAttemptingLayoutFollowUp,
layoutFollowUpStalledAttemptCount, layoutFollowUpObservers into
LayoutFollowUpState). Keep external behaviour by replacing the moved properties
on WorkspaceTab with computed property accessors that forward to the nested
structs and update initializers/usages (e.g., references to
pendingDetachedSurfaces, pendingCloseConfirmTabIds, isReconcilingFocusState,
layoutFollowUpReason) to the new nested types; ensure
Codable/Published/AnyCancellable semantics are preserved (keep `@Published` where
required) and run tests to fix any remaining call-sites.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 5885-5899: The new tab code is instantiating TerminalPanel
directly which creates local terminals for remote (SSH) workspaces; replace this
direct creation in createWorkspaceTab() with the existing remote-aware terminal
creation path by invoking the same helper used elsewhere (e.g.
newTerminalSurface(...) or the code that applies remoteTerminalStartupCommand()
and trackRemoteTerminalSurface()), so that remote startup commands and
remote-session tracking are applied; ensure you stop directly calling
TerminalPanel(...) and instead use the helper that returns/configures the panel,
then call configureTerminalPanel(...), set tab.panels[...] and
tab.panelTitles[...] with the returned panel id/displayTitle and still call
seedTerminalInheritanceFontPoints(...) as before.

In `@Sources/WorkspaceContentView.swift`:
- Around line 273-335: Hidden/ background WorkspaceTab instances are not having
their bonsplit metadata refreshed because syncBonsplitNotificationBadges() only
inspects workspace.bonsplitController and workspace.manualUnreadPanelIds; update
the sync to iterate per-tab controllers. Change syncBonsplitNotificationBadges()
(or add a new helper) to walk workspace.workspaceTabs and for each wsTab use
wsTab.bonsplitController and wsTab.manualUnreadPanelIds to update
badges/pins/kind, or trigger a per-tab sync when selectedWorkspaceTabIndex
changes so background tabs' metadata is refreshed when they become visible.
- Line 330: The remount key for each BonsplitView is currently built with
splitZoomRenderIdentity derived from workspace, causing zoom changes to remount
every stacked view; instead compute the zoom identity scoped to the wsTab and
use that in the .id. Replace
.id("\(wsTab.id.uuidString)-\(splitZoomRenderIdentity)") with a per-tab value
(e.g., read wsTab.bonsplitController.zoomedPaneId or the equivalent zoom ID on
wsTab) and use .id("\(wsTab.id.uuidString)-\(wsTabZoomId)") so only the affected
tab remounts, leaving hidden tabs alive.

In `@Sources/WorkspaceTab.swift`:
- Around line 25-26: Add a read-only computed property displayTitle on the
WorkspaceTab model that returns a non-empty, trimmed title: if customTitle
(trimmed) is non-empty return it, otherwise return the localized default e.g.
String(localized: "workspace.new-tab", defaultValue: "New Tab"); update any call
sites that currently do customTitle ?? derivedTitle to use displayTitle (or
derivedTitle where appropriate) so UI never receives an empty string; ensure
localization API and trimming are used inside the displayTitle implementation
and keep customTitle as-is for editing purposes.

---

Duplicate comments:
In `@Sources/ContentView.swift`:
- Around line 5241-5244: The palette context currently only records tab count
via snapshot.setBool(CommandPaletteContextKeys.workspaceHasMultipleTabs,
workspace.workspaceTabs.count > 1) so commands still display the parent
workspaceName instead of the selected tab; update the context to also capture
the active workspace-tab name (e.g., set a new or existing key like
CommandPaletteContextKeys.workspaceTabName or similar) with the selected tab's
name (derived from workspace.workspaceTabs[workspace.activeTabIndex] or the
workspace.activeTab property) and ensure the same change is applied where
workspaceName is read (around the code that handles the
Close/Next/Previous/Rename Workspace Tab entries at the 5307-5310 area) so those
commands show the selected tab name instead of the parent workspace.

In `@Sources/Workspace.swift`:
- Around line 6087-6102: syncActivePanels() replaces the activePanels publisher
but doesn't refresh focus-derived mirrors (currentDirectory, gitBranch,
pullRequest); update it to re-use the existing tab-selection path by calling the
same focus-refresh routine used on tab selection (e.g. invoke
reassertWorkspaceTabFocus() or the shared tab-selection handler) immediately
after swapping tab and setting activePanels so responder-derived mirrors are
recomputed; ensure the same change is applied in the sibling block noted (the
duplicate at 6104-6121) so both paths keep mirror state in sync when tabs are
switched/closed/restored.
- Around line 240-243: Currently tab snapshots are only built when
workspaceTabs.count > 1, which drops per-tab-only state (like a lone tab's
customTitle) for single-tab workspaces; update the logic so that you call
sessionWorkspaceTabSnapshot(tab:includeScrollback:) for the lone tab as well
whenever any tab contains per-tab-only state (e.g. customTitle) — i.e. compute a
boolean like hasPerTabState = workspaceTabs.contains { $0.customTitle != nil /*
or other per-tab-only props */ } and then set tabSnapshots = workspaceTabs.map {
sessionWorkspaceTabSnapshot(tab: $0, includeScrollback: includeScrollback) } if
workspaceTabs.count > 1 || hasPerTabState, otherwise nil.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 4402-4412: The helper requestCommandPaletteRenameWorkspaceTab has
a shortcut-specific default for its source parameter
("shortcut.renameWorkspaceTab") which makes provenance incorrect for
non-shortcut callers; change the default source to an API-neutral value (e.g.,
"api.commandPaletteRenameWorkspaceTab") and update the shortcut-specific caller
to pass "shortcut.renameWorkspaceTab" explicitly when invoking
postCommandPaletteRequest; ensure the same change is applied to the other
duplicate helper occurrence (the other requestCommandPaletteRenameWorkspaceTab
definition) so both use the neutral default and the shortcut path supplies the
explicit shortcut source.
- Line 9404: The comment "Workspace tab navigation: Cmd+Opt+] / Cmd+Opt+[ /
Cmd+Shift+T" is inconsistent with the actual default binding for
.newWorkspaceTab in KeyboardShortcutSettings (which is ⌥⌘T); update the comment
to match the real default or make it generic. Locate the comment near the
workspace tab navigation section in AppDelegate.swift and either change
"Cmd+Shift+T" to "Cmd+Opt+T" (or to "⌥⌘T") or replace the specific binding with
a generic phrase like "use your configured shortcut (see
KeyboardShortcutSettings.newWorkspaceTab)"; ensure you reference
.newWorkspaceTab/KeyboardShortcutSettings in the updated comment so it stays
accurate.

In `@Sources/cmuxApp.swift`:
- Around line 168-173: Multiple `@AppStorage` Data properties repeat the same
decode/fallback pattern; introduce a small helper that wraps
decodeShortcut(from:fallback:) and use it when initializing/reading these
shortcuts. Create a function (e.g. loadShortcut(fromData:fallback:) or a
computed property initializer) that takes the Data AppStorage value
(nextWorkspaceTabShortcutData, prevWorkspaceTabShortcutData,
newWorkspaceTabShortcutData, renameWorkspaceTabShortcutData,
closeWorkspaceTabShortcutData, closeOtherTabsInPaneShortcutData) and returns the
decoded KeyboardShortcut with the provided fallback, then replace the repeated
decode/fallback calls with calls to that helper (referencing
decodeShortcut(from:fallback:) inside it) to remove duplication.
- Around line 790-808: The Next/Previous/Close workspace tab commands should be
disabled when the selected workspace has only one tab; update the
splitCommandButton invocations that call
activeTabManager.selectNextWorkspaceTab, selectPreviousWorkspaceTab, and
closeSelectedWorkspaceTab to compute a boolean like hasMultipleTabs =
(activeTabManager.selectedWorkspace?.tabs.count ?? 0) > 1 (or use an existing
helper on activeTabManager) and apply that to the button’s disabled state (e.g.,
.disabled(!hasMultipleTabs) or the equivalent API) so the commands are not
enabled for single-tab workspaces.

In `@Sources/WorkspaceTab.swift`:
- Around line 82-153: WorkspaceTab currently mixes multiple independent
substates in-line (close confirmation, focus reconciliation, detach/attach,
layout follow-up) making invariants hard to follow; extract these into small
nested types (e.g., WorkspaceTab.CloseState, WorkspaceTab.FocusReconcileState,
WorkspaceTab.DetachState, WorkspaceTab.LayoutFollowUpState) and move the related
stored properties into each respective struct (for example move
pendingCloseConfirmTabIds, forceCloseTabIds, explicitUserCloseTabIds,
postCloseSelectTabId into CloseState; isReconcilingFocusState,
focusReconcileScheduled and related debug counters into FocusReconcileState;
detachingTabIds, pendingDetachedSurfaces, activeDetachCloseTransactions into
DetachState; layoutFollowUpObservers, layoutFollowUpPanelsCancellable,
layoutFollowUpTimeoutWorkItem, layoutFollowUpReason,
layoutFollowUpTerminalFocusPanelId, layoutFollowUpBrowserPanelId,
layoutFollowUpBrowserExitFocusPanelId, layoutFollowUpNeedsGeometryPass,
layoutFollowUpAttemptScheduled, layoutFollowUpStalledAttemptCount,
isAttemptingLayoutFollowUp, layoutFollowUpStalledAttemptCount,
layoutFollowUpObservers into LayoutFollowUpState). Keep external behaviour by
replacing the moved properties on WorkspaceTab with computed property accessors
that forward to the nested structs and update initializers/usages (e.g.,
references to pendingDetachedSurfaces, pendingCloseConfirmTabIds,
isReconcilingFocusState, layoutFollowUpReason) to the new nested types; ensure
Codable/Published/AnyCancellable semantics are preserved (keep `@Published` where
required) and run tests to fix any remaining call-sites.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 64d11e70-7ba8-477d-ae27-390fc0e7c4ec

📥 Commits

Reviewing files that changed from the base of the PR and between d590056 and d18b121.

📒 Files selected for processing (11)
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceContentView.swift
  • Sources/WorkspaceTab.swift
  • Sources/WorkspaceTabBarView.swift
  • Sources/cmuxApp.swift
  • cmuxTests/SessionPersistenceTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Sources/SessionPersistence.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/TabManager.swift

Comment thread Sources/Workspace.swift
Comment on lines +5885 to +5899
// Create a default terminal panel in the new tab using the workspace's
// current directory.
let terminalPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: nil,
workingDirectory: currentDirectory,
portOrdinal: portOrdinal
)
configureTerminalPanel(terminalPanel)
// Write directly to the tab (not through pass-throughs, which point to
// the selected tab -- which is now this new tab, but let's be explicit).
tab.panels[terminalPanel.id] = terminalPanel
tab.panelTitles[terminalPanel.id] = terminalPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

createWorkspaceTab() is creating local terminals inside remote workspaces.

This path seeds the first TerminalPanel directly instead of reusing the remote-aware terminal creation logic. Unlike newTerminalSurface(...), it never applies remoteTerminalStartupCommand() or trackRemoteTerminalSurface(...), so a new workspace tab under an SSH workspace opens a local shell, can inherit a remote-only currentDirectory, and falls out of remote-session accounting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 5885 - 5899, The new tab code is
instantiating TerminalPanel directly which creates local terminals for remote
(SSH) workspaces; replace this direct creation in createWorkspaceTab() with the
existing remote-aware terminal creation path by invoking the same helper used
elsewhere (e.g. newTerminalSurface(...) or the code that applies
remoteTerminalStartupCommand() and trackRemoteTerminalSurface()), so that remote
startup commands and remote-session tracking are applied; ensure you stop
directly calling TerminalPanel(...) and instead use the helper that
returns/configures the panel, then call configureTerminalPanel(...), set
tab.panels[...] and tab.panelTitles[...] with the returned panel id/displayTitle
and still call seedTerminalInheritanceFontPoints(...) as before.

Comment on lines +273 to 335
ForEach(Array(workspace.workspaceTabs.enumerated()), id: \.element.id) { tabIndex, wsTab in
let isSelectedTab = tabIndex == workspace.selectedWorkspaceTabIndex
let isSplit = wsTab.bonsplitController.allPaneIds.count > 1 ||
wsTab.panels.count > 1

BonsplitView(controller: wsTab.bonsplitController) { tab, paneId in
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = wsTab.panels[wsTab.surfaceIdToPanelId[tab.id] ?? UUID()] {
let isFocused = isWorkspaceInputActive && isSelectedTab && workspace.focusedPanelId == panel.id
let isSelectedInPane = wsTab.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible && isSelectedTab,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let showsNotificationRing = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
forTabId: workspace.id,
surfaceId: panel.id
),
isManuallyUnread: wsTab.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
paneId: paneId,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: isSelectedTab ? workspacePortalPriority : 0,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay,
onFocus: {
guard isWorkspaceInputActive, isSelectedTab else { return }
guard wsTab.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive, isSelectedTab else { return }
guard wsTab.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
wsTab.bonsplitController.focusPane(paneId)
}
} else {
EmptyPanelView(workspace: workspace, paneId: paneId)
}
} emptyPane: { paneId in
EmptyPanelView(workspace: workspace, paneId: paneId)
.onTapGesture {
wsTab.bonsplitController.focusPane(paneId)
}
}
} else {
// Fallback for tabs without panels (shouldn't happen normally)
EmptyPanelView(workspace: workspace, paneId: paneId)
.internalOnlyTabDrag()
.id("\(wsTab.id.uuidString)-\(splitZoomRenderIdentity)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(isSelectedTab ? 1 : 0)
.allowsHitTesting(isSelectedTab)
.zIndex(isSelectedTab ? 1 : 0)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hidden workspace tabs won't get their Bonsplit metadata refreshed.

This render path is tab-scoped now, but syncBonsplitNotificationBadges() below still only walks workspace.bonsplitController / workspace.manualUnreadPanelIds. Background WorkspaceTabs can keep stale badge, pin, and kind state until another sync happens after they become active. Please sync each wsTab.bonsplitController with the tab-local maps/sets, or at least resync on selectedWorkspaceTabIndex changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceContentView.swift` around lines 273 - 335, Hidden/
background WorkspaceTab instances are not having their bonsplit metadata
refreshed because syncBonsplitNotificationBadges() only inspects
workspace.bonsplitController and workspace.manualUnreadPanelIds; update the sync
to iterate per-tab controllers. Change syncBonsplitNotificationBadges() (or add
a new helper) to walk workspace.workspaceTabs and for each wsTab use
wsTab.bonsplitController and wsTab.manualUnreadPanelIds to update
badges/pins/kind, or trigger a per-tab sync when selectedWorkspaceTabIndex
changes so background tabs' metadata is refreshed when they become visible.

// Fallback for tabs without panels (shouldn't happen normally)
EmptyPanelView(workspace: workspace, paneId: paneId)
.internalOnlyTabDrag()
.id("\(wsTab.id.uuidString)-\(splitZoomRenderIdentity)")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "WorkspaceContentView id wiring:"
rg -n -C2 'splitZoomRenderIdentity|\.id\(' Sources/WorkspaceContentView.swift

echo
echo "Workspace.bonsplitController / activeWorkspaceTab wiring:"
rg -n -C3 'var bonsplitController|activeWorkspaceTab' Sources --type=swift

Repository: manaflow-ai/cmux

Length of output: 23709


🏁 Script executed:

rg -n 'class WorkspaceTab|struct WorkspaceTab' Sources --type=swift -A 20

Repository: manaflow-ai/cmux

Length of output: 5830


🏁 Script executed:

rg -n 'var bonsplitController' Sources/WorkspaceTab.swift --type=swift -B 2 -A 3

Repository: manaflow-ai/cmux

Length of output: 42


Scope the remount key to wsTab, not workspace.

splitZoomRenderIdentity still comes from workspace.bonsplitController.zoomedPaneId, so zooming the selected workspace tab—or switching to one with a different zoom state—changes the .id for every stacked BonsplitView. That tears down hidden tabs and defeats the keep-alive/anti-ghosting strategy this ZStack is trying to preserve.

🔧 Proposed fix
-                .id("\(wsTab.id.uuidString)-\(splitZoomRenderIdentity)")
+                .id("\(wsTab.id.uuidString)-\(wsTab.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed")")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.id("\(wsTab.id.uuidString)-\(splitZoomRenderIdentity)")
.id("\(wsTab.id.uuidString)-\(wsTab.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed")")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceContentView.swift` at line 330, The remount key for each
BonsplitView is currently built with splitZoomRenderIdentity derived from
workspace, causing zoom changes to remount every stacked view; instead compute
the zoom identity scoped to the wsTab and use that in the .id. Replace
.id("\(wsTab.id.uuidString)-\(splitZoomRenderIdentity)") with a per-tab value
(e.g., read wsTab.bonsplitController.zoomedPaneId or the equivalent zoom ID on
wsTab) and use .id("\(wsTab.id.uuidString)-\(wsTabZoomId)") so only the affected
tab remounts, leaving hidden tabs alive.

Comment on lines +25 to +26
/// User-set custom title for this tab. When nil, the tab bar derives a title from panel state.
@Published var customTitle: String?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Expose one non-empty display title from the model.

Line 213 falls back to "", and customTitle can also be blank. A caller using customTitle ?? derivedTitle can therefore render an invisible tab label for a fresh tab or cleared rename input. I'd expose a read-side displayTitle that trims customTitle and applies the localized "New Tab" fallback once.

💡 Possible fix
+    var displayTitle: String {
+        if let customTitle = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines),
+           !customTitle.isEmpty {
+            return customTitle
+        }
+        return derivedTitle.isEmpty
+            ? String(localized: "workspaceTab.new", defaultValue: "New Tab")
+            : derivedTitle
+    }
+
     /// The derived display title for this tab, preferring the focused panel's directory.
     var derivedTitle: String {
As per coding guidelines, `**/*.swift`: "All user-facing strings must be localized using `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI."

Also applies to: 191-214

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceTab.swift` around lines 25 - 26, Add a read-only computed
property displayTitle on the WorkspaceTab model that returns a non-empty,
trimmed title: if customTitle (trimmed) is non-empty return it, otherwise return
the localized default e.g. String(localized: "workspace.new-tab", defaultValue:
"New Tab"); update any call sites that currently do customTitle ?? derivedTitle
to use displayTitle (or derivedTitle where appropriate) so UI never receives an
empty string; ensure localization API and trimming are used inside the
displayTitle implementation and keep customTitle as-is for editing purposes.

@oobagi
Copy link
Copy Markdown
Author

oobagi commented Mar 27, 2026

This is my first PR, please give feedback or suggestions if anything needs changing!

Merged upstream changes including:
- Niri canvas experimental feature (NiriCanvasView)
- Ghostty submodule sync
- Remote terminal child exit tracking
- cmux config custom commands in command palette
- Titlebar double-click monitor

All conflicts resolved preserving both workspace tabs code and upstream additions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Sources/TabManager.swift (1)

1327-1358: ⚠️ Potential issue | 🟡 Minor

Pin the welcome command to the original terminal panel.

finishIfReady() and the readiness observer both resolve through workspace.focusedTerminalPanel. With workspace tabs, the active tab can change before the initial terminal becomes ready, so the delayed cmux welcome can be sent to a different tab and still mark onboarding as shown. Capture the original workspace-tab/panel target before subscribing and gate readiness against that exact target instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1327 - 1358, finishIfReady() currently
uses workspace.focusedTerminalPanel which can change; capture the initial target
terminal panel (e.g., let initialPanel = workspace.focusedTerminalPanel) before
creating panelsCancellable/readyObserver, then change finishIfReady() and the
notification closure to check and act only if the panel equals that captured
initialPanel (and its surface is non-nil). Also use the captured initialPanel
when sending "cmux welcome\n" and when removing observers/cancelling so the
onboarding is pinned to the original terminal panel.
Sources/AppDelegate.swift (1)

6356-6367: ⚠️ Potential issue | 🟡 Minor

This can send the queued text to the wrong workspace tab.

Workspace.panelsPublisher mirrors the selected workspace tab’s panels, and finishIfReady() resolves tab.focusedTerminalPanel at send time. If the user switches workspace tabs before the original terminal becomes ready, the queued input follows the new selection and lands there instead of in the tab that spawned the request. Capture the target workspace-tab/panel up front and wait on that fixed target instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 6356 - 6367, The current subscription
uses tab.panelsPublisher and the terminalSurfaceDidBecomeReady notification
which resolve tab.focusedTerminalPanel at send time, so if the user switches
tabs the queued input may go to the wrong workspace; change the logic to capture
the intended workspace tab and target panel up front (e.g. store a reference or
unique identifier from tab and the specific Panel/TerminalPanel instance you
intend to target) and then wait for readiness for that fixed target instead of
responding to the selected tab’s panelsPublisher—replace the panelsPublisher
sink and the generic notification handler in the panelsCancellable/readyObserver
setup with observers that filter by the captured panel identifier or directly
observe that panel’s readiness, and have finishIfReady() operate on the captured
panel reference rather than tab.focusedTerminalPanel.
♻️ Duplicate comments (1)
Sources/TabManager.swift (1)

5484-5488: ⚠️ Potential issue | 🟠 Major

Hash the persisted workspace-tab snapshot, not just counts.

This closes the original create/close hole, but it still misses non-count mutations that the nested workspace-tab snapshot persists. A non-selected workspace tab can change its independent split/layout state while workspaceTabs.count, selectedWorkspaceTabIndex, and wsTab.panels.count all stay the same, so autosave can still skip a dirty session. The safest fix is to derive this fingerprint from the same per-tab snapshot data that sessionSnapshot(...) persists.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5484 - 5488, The current hashing only
uses counts (workspace.workspaceTabs.count, workspace.selectedWorkspaceTabIndex,
wsTab.panels.count) which misses non-count mutations; instead, derive the
workspace fingerprint from the same per-tab snapshot data used by
sessionSnapshot(...). Replace the per-tab hasher.combine(wsTab.panels.count)
(and the other count-only combines) with a loop that produces each tab's
persisted snapshot (call the existing sessionSnapshot(...) or the helper that
the saver uses for a workspace tab) and feed that snapshot (or its stable
representation) into hasher.combine so any nested/layout changes will change the
fingerprint; keep workspace.selectedWorkspaceTabIndex combined as well but stop
relying on counts alone.
🧹 Nitpick comments (2)
Sources/cmuxApp.swift (1)

777-795: Consider matching the one-tab affordance in the View menu.

Next/Previous/Close Workspace Tab stay available even when the tab bar auto-hides and the command palette suppresses those actions for a single-tab workspace. Disabling or conditionally showing them here would avoid dead menu items and shortcuts in that state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/cmuxApp.swift` around lines 777 - 795, The Next/Previous/Close
Workspace Tab menu entries (the splitCommandButton calls that call
activeTabManager.selectNextWorkspaceTab(), selectPreviousWorkspaceTab(), and
closeSelectedWorkspaceTab()) remain enabled for single-tab workspaces and should
be disabled or hidden to match the command-palette behavior; update those
splitCommandButton invocations to conditionally disable (or not show) the menu
items and their shortcuts when the current workspace has only one tab — e.g.,
query the activeTabManager for the selected workspace tab count (selected
workspace / selectedWorkspace?.tabs.count or an equivalent API on
activeTabManager) and return disabled/hidden for count <= 1; apply the same
conditional visibility/enablement logic for the Close Workspace Tab entry (and
optionally for Rename if you choose) so shortcuts are not active in single-tab
workspaces.
Sources/AppDelegate.swift (1)

9587-9587: Avoid hard-coding concrete key combos in comments here.

These handlers already read from KeyboardShortcutSettings, so an inline shortcut note will drift as soon as defaults change or users remap the binding. An action-only comment would age better.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` at line 9587, Replace the hard-coded key combo
text in the comment with an action-only note that references the shortcut
configuration (e.g., change "// Workspace tab navigation: Cmd+Opt+] / Cmd+Opt+[
/ Cmd+Shift+T" to something like "// Workspace tab navigation (uses
KeyboardShortcutSettings)"); update any nearby comments that mention concrete
keys so they instead point to KeyboardShortcutSettings or the specific handlers
that read those settings (the workspace tab navigation handlers) so the comment
won't drift when defaults or user bindings change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@Sources/AppDelegate.swift`:
- Around line 6356-6367: The current subscription uses tab.panelsPublisher and
the terminalSurfaceDidBecomeReady notification which resolve
tab.focusedTerminalPanel at send time, so if the user switches tabs the queued
input may go to the wrong workspace; change the logic to capture the intended
workspace tab and target panel up front (e.g. store a reference or unique
identifier from tab and the specific Panel/TerminalPanel instance you intend to
target) and then wait for readiness for that fixed target instead of responding
to the selected tab’s panelsPublisher—replace the panelsPublisher sink and the
generic notification handler in the panelsCancellable/readyObserver setup with
observers that filter by the captured panel identifier or directly observe that
panel’s readiness, and have finishIfReady() operate on the captured panel
reference rather than tab.focusedTerminalPanel.

In `@Sources/TabManager.swift`:
- Around line 1327-1358: finishIfReady() currently uses
workspace.focusedTerminalPanel which can change; capture the initial target
terminal panel (e.g., let initialPanel = workspace.focusedTerminalPanel) before
creating panelsCancellable/readyObserver, then change finishIfReady() and the
notification closure to check and act only if the panel equals that captured
initialPanel (and its surface is non-nil). Also use the captured initialPanel
when sending "cmux welcome\n" and when removing observers/cancelling so the
onboarding is pinned to the original terminal panel.

---

Duplicate comments:
In `@Sources/TabManager.swift`:
- Around line 5484-5488: The current hashing only uses counts
(workspace.workspaceTabs.count, workspace.selectedWorkspaceTabIndex,
wsTab.panels.count) which misses non-count mutations; instead, derive the
workspace fingerprint from the same per-tab snapshot data used by
sessionSnapshot(...). Replace the per-tab hasher.combine(wsTab.panels.count)
(and the other count-only combines) with a loop that produces each tab's
persisted snapshot (call the existing sessionSnapshot(...) or the helper that
the saver uses for a workspace tab) and feed that snapshot (or its stable
representation) into hasher.combine so any nested/layout changes will change the
fingerprint; keep workspace.selectedWorkspaceTabIndex combined as well but stop
relying on counts alone.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Line 9587: Replace the hard-coded key combo text in the comment with an
action-only note that references the shortcut configuration (e.g., change "//
Workspace tab navigation: Cmd+Opt+] / Cmd+Opt+[ / Cmd+Shift+T" to something like
"// Workspace tab navigation (uses KeyboardShortcutSettings)"); update any
nearby comments that mention concrete keys so they instead point to
KeyboardShortcutSettings or the specific handlers that read those settings (the
workspace tab navigation handlers) so the comment won't drift when defaults or
user bindings change.

In `@Sources/cmuxApp.swift`:
- Around line 777-795: The Next/Previous/Close Workspace Tab menu entries (the
splitCommandButton calls that call activeTabManager.selectNextWorkspaceTab(),
selectPreviousWorkspaceTab(), and closeSelectedWorkspaceTab()) remain enabled
for single-tab workspaces and should be disabled or hidden to match the
command-palette behavior; update those splitCommandButton invocations to
conditionally disable (or not show) the menu items and their shortcuts when the
current workspace has only one tab — e.g., query the activeTabManager for the
selected workspace tab count (selected workspace / selectedWorkspace?.tabs.count
or an equivalent API on activeTabManager) and return disabled/hidden for count
<= 1; apply the same conditional visibility/enablement logic for the Close
Workspace Tab entry (and optionally for Rename if you choose) so shortcuts are
not active in single-tab workspaces.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4dc319b0-256d-42c6-945f-6ca76cdbd8fd

📥 Commits

Reviewing files that changed from the base of the PR and between d18b121 and 02aa81d.

📒 Files selected for processing (8)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceContentView.swift
  • Sources/cmuxApp.swift
  • cmuxTests/SessionPersistenceTests.swift
✅ Files skipped from review due to trivial changes (1)
  • GhosttyTabs.xcodeproj/project.pbxproj
🚧 Files skipped from review as they are similar to previous changes (3)
  • Sources/WorkspaceContentView.swift
  • Sources/ContentView.swift
  • Sources/Workspace.swift

@oobagi
Copy link
Copy Markdown
Author

oobagi commented Mar 27, 2026

All code review bot feedback has been addressed in the second commit:

Critical fixes:

  • panels pass-through now iterates all workspace tabs for workspace-wide operations (close confirmation, teardown, portal hiding)
  • closeWorkspaceTab now shows a confirmation dialog for running processes and runs the full cleanup pipeline

Major fixes:

  • Resolved ⌥⌘T shortcut collision — moved "Close Other Tabs in Pane" to ⌥⌘X (now user-configurable via KeyboardShortcutSettings)
  • Fixed retain cycle in onFileDrop closure
  • Session autosave fingerprint now includes workspace tab mutations
  • Custom tab titles persist across session save/restore
  • Bare-tab restore cleans up Bonsplit Welcome tabs

Minor fixes:

  • Tab title prefers focused panel's directory over UUID-sort order
  • Fallback "New Tab" string localized
  • Rename flow prefills with the derived title

Merge conflicts with upstream main have also been resolved. Build compiles clean and all existing unit tests pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant